透過自動化測試取代人工測試,降低測試成本,自動化測試帶來速度快、可重複與自動化的測試工程。
程式寫完之後,通常會需要做測試,可能就是跑跑看看東西或 log 內容有沒有符合預期,手動測試很麻煩,也容易忘東忘西,流程繁雜時手動測試也不是個好選擇,Angular 內建單元測試 (Unit Test) 功能,可以幫你解決這些問題,Angular 期望框架使用者能,
單元測試 (Unit testing)
以程式碼的最小單位進行測試,保護程式邏輯不會在系統維護的過程中遭到破壞,也進一步確保維護中的程式碼品質。
整合測試 (Integration testing)
整合多方資源進行測試,確保模組與模組之間的互動行為正確,也讓不同模組在各自開發維護的過程中不會因為功能調整而遭到破壞。 一般來說,整合測試的數量應該介於單元測試與端對端測試之間,針對幾個主要的模組進行整合測試即可。
端對端測試 (E2E testing)
所謂的「端對端」(E2E) 是指從使用者的角度出發,對真實系統進行測試。
透過人工對已經完整部屬的網站進行測試,因此可以驗證出系統是否符合客戶的實際需求。這部分也可以透過撰寫 E2E 測試程式來進行自動化,增加測試效率。
測試環境就是一套完整的系統部署,如果能透過 Docker 容器技術進行部署,那麼設立測試環境的成本將會大幅降低。由於端對端測試可以正確反映出使用者需求是否滿足,因此商業價值較高,但要對一個複雜的系統進行完整的 E2E 測試開發,可能會有相當多的測試案例,如果真的要做到 100% 的測試覆蓋率,成本也會相對的提高許多!
傳統人工的端對端測試,經常會發生下列問題
自動化的測試取代傳統人工測試
原本跑一輪測試要花上 20 分鐘,寫完測試程式後,可能只需要 20 秒就可以完成,對一個穩定的產品或需要長時間維護的專案來說,自動化的端對端測試除了一開始需要投入人力與時間開發代碼之外,最終所節省的成本理論上會遠大於人工測試的成本。
因此,導入自動化的端對端測試有其必要性,尤其像是針對價值型高的網站 (如電子商務、品牌形象網站)、操作動線複雜的企業表單都可以考慮開始慢慢加入自動化的端對端測試,用程式來跑測試。
如何導入自動化的端對端測試
不需花時間開發自動測試的專案
需求不確定 (是否有穩定的需求需要被保護)
專案建置初期,需求尚未明朗又必須交付成果的時候,撰寫測試就顯得不具意義,因為當需求改變時,測試程式肯定也要跟著改寫,開發成本也會跟著墊高。 業界有很多人推廣 TDD (測試先行開發)。但是這樣的點子不見得適用於所有專案類型,像是 PoC (驗證可行性) 的專案,對於設計架構上就沒有必要。
價值性不高 (是否有重要的功能需要被保護)
取決於網站經營者對價值的設定,其實跟工程師沒多大關係。我們都知道撰寫測試需時間,不願意給予合理的工時撰寫測試,自然也就沒有撰寫測試的可能性。如果老闆請人把網站功能全部使用一遍 (端對端測試),就能知道網站是否可用,那他會毫不遲疑的請個人去測一遍網站所有功能。因為這是他唯一能理解的價值呈現方式!
單元測試 與 整合測試
建議專案時程延長一倍,或有多餘人力時再進行這部份測試的自動化,原因如下,
端對端測試
以真實完整的系統進行測試,從開啟瀏覽器開始,一步步的操作與輸入,並且讓瀏覽器與後端 API 進行互動,然後直接透過最終的顯示狀態來診斷其結果是否符合預期。如果測試結果正確,通常也意味著「需求正確」,因此這可以說是最容易讓客戶、老闆放心的測試類型。也是為什麼一般公司寧願不去寫單元測試、整合測試,卻願意投入端對端測試的原因。
元件需求如果不明確或功能切分不明,會提高單元測試代碼的維護成本。
Angular 附帶 Jasmine,這是一個 JavaScript 框架,使您能夠編寫和執行單元和集成測試。茉莉花由三個重要部分組成:
具有用於構建測試的類和函數的庫。 測試執行引擎。 以不同格式輸出測試結果的報告引擎。
使用Angular cli指令 ng test
將執行專案中所有的 spec檔案
執行畫面如下(全正確時)
執行畫面如下(有錯誤時)
同一個模組的測試寫在一個 test suite裡,避免測試項目分散。
單元測試撰寫規則
單元 - Component
it('### 選擇聊天室 clickChatRoom [設定被選擇聊天室的未讀狀態]', () => {
component.chatRoomList = fakeChatRoomList;
// [Spy] navigateTo如果被調用回傳 undefined
utilitiesServiceSpy.navigateTo.and.returnValue();
component.clickChatRoom(0);
// navigateTo被執行一次
expect(utilitiesServiceSpy.navigateTo.calls.count()).toBe(1);
expect(component.chatRoomList[0].FIsUnRead).toBeFalse();
});
測試撰寫規則
基本的單元測試(日期時間格式產生器)
describe('### 日期時間格式產生器', () => {
it('### 時間為空時的回傳值', () => {
const item = service.dateTimeFormat(null);
expect(item).toBeUndefined();
});
it('### 時間為 timestamp時的回傳值', () => {
const timeStr = service.dateTimeFormat(+new Date());
// 回傳值為字串
expect(typeof timeStr === 'string').toBe(true);
// 回傳值 Date物件存在 getMonth方法
expect(typeof new Date(timeStr).getMonth === 'function').toBe(true);
});
});
測試撰寫規則
使用 for loop逐一進行單元測試
describe('### 判斷是否為 JSON格式', () => {
const input = [null, '{}', 'Hello', '{"a":"first","b":"second"}'];
const output = [false, true, false, true];
for (const i in input) {
it(`### 輸入 ${input[i]}`, () => {
expect(service.isJson(input[i])).toBe(output[i]);
});
}
});
Spinners載入中元件 [是否被建立]
describe('## Spinners載入中元件 SpinnerComponent', () => {
let component: SpinnerComponent;
let fixture: ComponentFixture<SpinnerComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [SpinnerComponent],
}).compileComponents();
fixture = TestBed.createComponent(SpinnerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}));
it('### Spinners載入中元件 [是否被建立]', () => {
expect(component).toBeTruthy();
});
});
describe('## 無害化處理 SafePipe', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [BrowserModule],
});
});
it('### 無害化處理 [是否被建立]', () => {
const domSanitizer = TestBed.get(DomSanitizer);
const pipe = new SafePipe(domSanitizer);
expect(pipe).toBeTruthy();
});
it('### 無害化HTML [是否為安全HTML]', () => {
const domSanitizer = TestBed.get(DomSanitizer);
const pipe = new SafePipe(domSanitizer);
const SafeHtml = pipe.transform('<p>Unit Test</p>', 'html');
expect(typeof SafeHtml).toBe('object');
});
});
describe('## 防止連續點擊元件 DebounceClickDirective', () => {
let component: DebounceClickDirectiveTestingComponent;
let fixture: ComponentFixture<DebounceClickDirectiveTestingComponent>;
let inputElem: DebugElement;
let subscription: Subscription;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [FormsModule],
declarations: [DebounceClickDirective, DebounceClickDirectiveTestingComponent],
}).compileComponents();
subscription = new Subscription();
spyOn(subscription, 'unsubscribe').and.callThrough();
fixture = TestBed.createComponent(DebounceClickDirectiveTestingComponent);
fixture.detectChanges();
component = fixture.componentInstance;
inputElem = fixture.debugElement.query(By.css('input'));
});
it('### 防止連續點擊元件 [是否被建立]', () => {
const directive = new DebounceClickDirective();
expect(directive).toBeTruthy();
});
it('### 防止連續點擊元件 [500ms後啟動點擊事件]', fakeAsync(() => {
const directiveElem = fixture.debugElement.query(By.directive(DebounceClickDirective));
spyOn(component, 'test').and.stub();
fixture.detectChanges();
expect(directiveElem).toBeDefined();
expect(component.directive.debounceTime).toBe(500);
expect(component.value).toBe('');
inputElem.nativeElement.value = 'test';
inputElem.nativeElement.dispatchEvent(new Event('input'));
tick(500);
fixture.detectChanges();
expect(component.test).toHaveBeenCalled();
expect(component.value).toBe('test');
component = null;
}));
});
私有方法測試範例 使用 Spy製造 mock response並監聽調用
describe('### 方法自動測試', () => {
const fakeChatRoomList = [
{
FId: '17d6fe9f-8320-0932-4a23-00155dd13fcf',
FIcon: '測試',
FIsUnRead: true,
FUserName: '測試',
FUpdateTime: '2021-12-09 14:54:07.000',
FLastMessage: 'system2',
},
];
it('### 選擇聊天室 clickChatRoom [設定被選擇聊天室的未讀狀態]', () => {
component.chatRoomList = fakeChatRoomList;
// [Spy] navigateTo如果被調用回傳 undefined
utilitiesServiceSpy.navigateTo.and.returnValue();
component.clickChatRoom(0);
// navigateTo被執行一次
expect(utilitiesServiceSpy.navigateTo.calls.count()).toBe(1);
expect(component.chatRoomList[0].FIsUnRead).toBeFalse();
});
// 私有方法測試範例
it('### 清除聊天列表 clearChatList [是否清除 chatRoomList/chatRoomListPage]', () => {
component.chatRoomList = fakeChatRoomList;
component.chatRoomListPage.count = 1;
component = componentExtend.clearChatListExtend(component);
expect(component.chatRoomList.length === 0).toBeTrue();
expect(component.chatRoomListPage.count === 0).toBeTrue();
});
});
describe('## 使用 HTTP與後端服務進行通訊', () => {
let service: HttpService;
let httpMock: HttpTestingController;
let utilitiesServiceSpy: jasmine.SpyObj<UtilitiesService>;
beforeEach(() => {
const spy = jasmine.createSpyObj('UtilitiesService', ['configParser', 'getMockSession']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [{ provide: UtilitiesService, useValue: spy }],
});
service = TestBed.inject(HttpService);
httpMock = TestBed.inject(HttpTestingController);
utilitiesServiceSpy = TestBed.inject(UtilitiesService) as jasmine.SpyObj<UtilitiesService>;
});
it('### 使用 HTTP與後端服務進行通訊 [是否被建立]', () => {
expect(service).toBeTruthy();
});
it('### httpGET [期待並回復請求]', () => {
service.httpGET('http://localhost:3000/ecp-agent-list');
const req = httpMock.expectOne({ method: 'GET' });
const resp = [{ result: 'Unit Test' }];
req.flush(resp);
expect(req.request.method).toEqual('GET');
expect(req.request.responseType).toEqual('json');
});
afterEach(() => {
// 驗證沒有發起過預期之外的請求
httpMock.verify();
});
});
describe('## 聯絡人註冊元件', () => {
describe('## 聯絡人註冊元件 [同意書頁面]', () => {
let component: RegisterUserComponent;
let fixture: ComponentFixture<RegisterUserComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule, RouterTestingModule, RouterTestingModule.withRoutes([])],
declarations: [RegisterUserComponent, SafePipe],
providers: [],
}).compileComponents();
fixture = TestBed.createComponent(RegisterUserComponent);
component = fixture.componentInstance;
component.i18n = JSON.parse(localStorage.getItem('languages'));
component.TEXTRESOURCE = TEXTRESOURCE;
component.ngOnInit();
fixture.detectChanges();
}));
it('### 同意書頁面 [勾選同意框 -> 點擊同意按鈕 -> 確認換到下一頁]', () => {
// 確認目前在同意書頁面
expect(component.pageMode).toBe('agreement');
fixture.whenStable().then(() => {
const debugElement: DebugElement = fixture.debugElement;
const htmlElement: HTMLElement = debugElement.nativeElement;
const checkbox = htmlElement.querySelectorAll('.form-check-input');
// 勾選同意框
checkbox[0]['checked'] = true;
component.isAgreementCheck = true;
fixture.detectChanges();
// 點擊同意按鈕
component.goRegister();
// 確認換到下一頁
expect(component.pageMode).toBe('form');
});
});
});
});
Angular 框架下開發的元件適合做單元測試,使用 Angular-CLI 建立元件時甚至會幫你自動建一個 .spec
的單元測試檔,可見 Angular 官方是希望框架使用者做好自動測試的,甚至是鼓勵 TDD 的開發流程,這時確保元件的獨立性與結構性就是一個重要的工作,否則單元測試會很難進行。
結束了元件的單元測試,接下來使用 Cypress 框架搭配 Angular 來進行 E2E 測試。
Testing Angular A Guide to Robust Angular Applications